/**
 * Copyright (C) 2009 Gero Vermaas <gero@vermaas.net>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package net.vermaas.blogger.pebble.fs;

import com.google.gdata.data.Category;
import com.google.gdata.data.DateTime;
import com.google.gdata.data.Entry;
import com.google.gdata.data.Person;
import com.google.gdata.data.PlainTextConstruct;
import com.google.gdata.data.TextConstruct;
import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Properties;
import java.util.StringTokenizer;
import java.util.TimeZone;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import net.vermaas.blogger.BlogPostDetails;
import net.vermaas.blogger.BlogSource;
import net.vermaas.blogger.pebble.PebbleBlogPost;
import org.apache.log4j.Logger;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

/**
 * Provides the details of all blog posts from the file system.
 * It requires the root of the pebble root as starting point to find
 * all posts.
 *
 * @author Gero Vermaas
 */
public class PebbleFSBlogSource implements BlogSource {
  private static final String ATOMNS = "http://www.blogger.com/atom/ns#";

  private String rootDirectory;
  private List<File> blogPostFiles;
  private int current = 0;

  /**
   * XPath expression to get a comment.
   */
  private final static String COMMENT_XPATH = "//blogEntry/comment";
  /**
   * XPath expression to get a category.
   */
  private final static String CATEGORY_XPATH = "//blogEntry/category";
  private final static String PEBBLE_FS_ROOT_PROP = "pebble.fsRoot";

  private static final Logger log = Logger.getLogger(PebbleFSBlogSource.class);

  /**
   * Lits of possible formats in which the date in the posts can be formatted.
   */
  private static String[] DATE_FORMATS = 
    new String[] {"dd MMM yyyy HH:mm:ss:SSS Z", "dd MMM yyyy HH:mm:ss:SSS z",
                  "dd MMM yyyy HH:mm:ss Z", "dd MMM yyyy HH:mm:ss z"};
  

  /**
   * {@inheritDoc}
   * @return {@inheritDoc}
   */
  public boolean hasNext() {
    if (blogPostFiles == null) {
      blogPostFiles = getAllBlogPostFiles();
    }
    return current < blogPostFiles.size();
  }

  /**
   * Returns the next blog post.
   * @return The next blog post details.
   */
  public BlogPostDetails next() {
    if (blogPostFiles == null) {
      blogPostFiles = getAllBlogPostFiles();
    }
    current++;
    try {
      return makeBlogPostDetails(blogPostFiles.get(current - 1));
    } catch (Exception e) {
      throw new RuntimeException("Failed to convert blog post", e);
    }
  }


  /**
   * Throws UnsupportedOperationException, not implemented.
   */
  public void remove() {
    throw new UnsupportedOperationException();
  }

  /**
   * Sets the root directory of the Pebble blog entries on the
   * filesystem. Typically something like [homedir]/pebble/blogs/[blogName].
   * @param dirName Name of the root directory of a the blog.
   */
  public void setRootDirectory(String dirName) {
    File dir = new File(dirName);
    if (!dir.exists()) {
      throw new RuntimeException("Provided rootDirectory does not exist: " +
        dirName);
    }

    rootDirectory = dirName;
  }

  /**
   * A list of File objects, each file contains the details of one blog post.
   * @return List of File objects, each file contains the details of one blog post.
   */
  List<File> getAllBlogPostFiles() {
    List<File> files = new ArrayList<File>();

    File rootDir = new File(rootDirectory);
    File[] dirs = rootDir.listFiles(new BlogDirectoryFilter());

    for (File dir : dirs) {
      addAllBlogPostFiles(dir, files);
    }

    return files;
  }

  /**
   * Returns all blog post files found in the (sub)directories of 'directory'.
   * @param directory The directory to start searching.
   * @param files The current list of File found.
   * @return All blog post Files found.
   */
  List<File> addAllBlogPostFiles(File directory, List files) {
    File[] dirs = directory.listFiles(new BlogDirectoryFilter());

    for (File dir : dirs) {
      addAllBlogPostFiles(dir, files);
    }

    for (File newFile: directory.listFiles(new BlogPostFilter())) {
      try {
        files.add(new File(newFile.getCanonicalPath()));
      } catch (IOException ex) {
        log.error("Could not create File object.", ex);
      }
    }
    return files;
  }

  /**
   * Parses 1 Pebble blog post file and constructs a BlogPostDetails instance
   * based on that file.
   * @param blogPostFile The file containing the blog post details.
   * @return The converted BlogPostDetails.
   * 
   * @throws javax.xml.transform.TransformerConfigurationException
   * @throws javax.xml.transform.TransformerException
   * @throws javax.xml.parsers.ParserConfigurationException
   * @throws org.xml.sax.SAXException
   * @throws java.io.IOException
   * @throws javax.xml.xpath.XPathExpressionException
   * @throws java.text.ParseException
   */
  BlogPostDetails makeBlogPostDetails(File blogPostFile)
    throws TransformerConfigurationException, TransformerException,
    ParserConfigurationException, SAXException, IOException,
    XPathExpressionException, ParseException {

    log.debug("makeBlogPostDetails for: " + blogPostFile.getAbsolutePath());
    Entry blogEntry = new Entry();

    DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
    factory.setNamespaceAware(true);
    DocumentBuilder builder = factory.newDocumentBuilder();
    Document doc = builder.parse(blogPostFile);

    TextConstruct tc = new PlainTextConstruct(getNodeValue(doc, "//blogEntry/title/text()"));
    blogEntry.setTitle(tc);

    tc = new PlainTextConstruct(getNodeValue(doc, "//blogEntry/excerpt/text()"));
    blogEntry.setSummary(tc);

    tc = new PlainTextConstruct(getNodeValue(doc, "//blogEntry/body/text()"));
    blogEntry.setContent(tc);

    DateTime dateTime = makeDateTime(getNodeValue(doc, "//blogEntry/date/text()"),
      getNodeValue(doc, "//blogEntry/timeZone/text()"));
    blogEntry.setPublished(dateTime);

    // Catgeories
    String category = null;
    int cCount = 1;
    while ((category = getNodeValue(doc, makeCategoryXPath(cCount))) != null) {
      StringTokenizer st = new StringTokenizer(category, "/");
      while (st.hasMoreTokens()) {
        blogEntry.getCategories().add(new Category(ATOMNS, st.nextToken()));
        log.debug("Added category: " + category);
      }
      cCount++;
    }

    // Tags
    // Tags are also added as catgeories
    String tags = getNodeValue(doc, "//blogEntry/tags/text()");
    if (tags != null) {
      StringTokenizer st = new StringTokenizer(tags, "/,");
      while (st.hasMoreTokens()) {
        blogEntry.getCategories().add(new Category(ATOMNS, st.nextToken()));
        log.debug("Added tas as category: " + category);
      }
    }

    // Comments
    List<Entry> comments = new ArrayList<Entry>();
    String cTitle = null;
    cCount = 1;
    while ((cTitle = getNodeValue(doc, makeCommentXPathFor(cCount, "title"))) != null) {
      Entry comment = new Entry();
      comment.setTitle(new PlainTextConstruct(cTitle));

      String body = getNodeValue(doc, makeCommentXPathFor(cCount, "body"));
      if (body != null) {
        // Body must contain content, otherwise Google API will reject comment.
        tc = new PlainTextConstruct(getNodeValue(doc, makeCommentXPathFor(cCount, "body")));
        comment.setContent(tc);

        dateTime = makeDateTime(getNodeValue(doc, makeCommentXPathFor(cCount, "date")),
          getNodeValue(doc, "//blogEntry/timeZone/text()"));
        comment.setPublished(dateTime);

        Person author = new Person();
        author.setName(getNodeValue(doc, makeCommentXPathFor(cCount, "author")));
        author.setEmail(getNodeValue(doc, makeCommentXPathFor(cCount, "email")));
        author.setUri(getNodeValue(doc, makeCommentXPathFor(cCount, "website")));

        comment.getAuthors().add(author);

        comments.add(comment);
      }
      cCount++;
    }

    String blogPostId = blogPostFile.getAbsolutePath().substring(rootDirectory.length());
    int iPos = blogPostId.indexOf(".xml");
    if (iPos != -1) {
      blogPostId = blogPostId.substring(0, iPos);
      blogPostId += ".html";
    }
    log.debug("original BlogPostId for: " + blogPostFile.getAbsolutePath() +
      " is " + blogPostId);

    return new PebbleBlogPost(blogEntry, comments, blogPostId);
  }

  /**
   * Create an XPath expression to query the n-th category element.
   * @param cCount The n-th category to get
   * @return
   */
  String makeCategoryXPath(int cCount) {
    return CATEGORY_XPATH + "[" + cCount + "]/text()";
  }
 
  /**
   * Create an XPath expression to query the n-th comment subelement named 'element'.
   * @param cCount The n-th comment to get
   * @param element The name of the subelement of the n-th comment to get.
   * @return
   */
   String makeCommentXPathFor(int cCount, String element) {
    return COMMENT_XPATH + "[" + cCount + "]/" + element + "/text()";
  }

   /**
    * Converts datetime string found in blog post to a DateTime object.
    * @param dateStr The datetime string.
    * @param timeZone The timezone (if null, the TimeZone.getDefault() will be used).
    * @return Constructed DateTime instance.
    * @throws java.text.ParseException
    */
  DateTime makeDateTime(String dateStr, String timeZone) throws ParseException {
    log.debug("makeDateTime, dateStr: " + dateStr + " TimeZone: " + timeZone);
    TimeZone tz = TimeZone.getDefault();
    if (timeZone != null) {
      tz = TimeZone.getTimeZone(timeZone);
    } else {
      log.debug("Using default timzone " + tz.getDisplayName()
        + " for blogPost because TimeZone info is missing");
    }
    return new DateTime(parseDate(dateStr), tz);
  }

  /**
   * Attempts to parse the string into a Date object. Tries the
   * formats defined in {@link DATE_FORMATS}.
   * @param dateStr The string date time.
   * @return Date value
   * @throws java.text.ParseException
   */
  Date parseDate(String dateStr) throws ParseException {
    for (String dfStr: DATE_FORMATS) {
      DateFormat df = new SimpleDateFormat(dfStr);
      try {
        return df.parse(dateStr);
      } catch (ParseException pe) {
        // Ignore and try next format
      }
      
    }
    throw new ParseException("Could not parse date with any of the defined formats", 0);

  }

  /**
   * Extracts a string value from the Document using the XPath expression
   * passed in.
   * @param doc The Document from which the value must be extracted.
   * @param expr The XPath expression to use
   * @return The found value
   * @throws javax.xml.xpath.XPathExpressionException
   */
  String getNodeValue(Document doc, String expr) throws XPathExpressionException {

    XPathFactory factory = XPathFactory.newInstance();
    XPath xpath = factory.newXPath();
    XPathExpression xpathExpr = xpath.compile(expr);

    Object result = xpathExpr.evaluate(doc, XPathConstants.NODESET);

    NodeList nodes = (NodeList) result;
    if (nodes.getLength() == 1) {
      return nodes.item(0).getNodeValue();
    } else {
      return null;
    }
  }

  /**
   * Set the properties to be used. {@link PEBBLE_FS_ROOT_PROP} is the
   * property that is actually used.
   * @param settings The properties to use.
   */
  public void setSettings(Properties settings) {
    setRootDirectory(settings.getProperty(PEBBLE_FS_ROOT_PROP));
  }

  /**
   * Filter to filters out directories that can contain
   * blog posts.
   */
  class BlogDirectoryFilter implements FileFilter {

    public boolean accept(File pathname) {
      if (pathname.isFile()) {
        return false;
      }

      String dirName = pathname.getName();
      if (dirName.length() != 4 && dirName.length() != 2) {
        return false;
      }

      try {
        Integer.parseInt(dirName);
      } catch (NumberFormatException ne) {
        return false;
      }

      return true;
    }
  }

  /**
   * Filter to dilter out files containing blogs posts.
   */
  class BlogPostFilter implements FileFilter {

    public boolean accept(File pathname) {
      if (pathname.isDirectory()) {
        return false;
      }
      return pathname.getName().endsWith(".xml");
    }
  }
}
